package com.iss.httpclient.core;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.Map;
import java.util.TreeMap;

/**
 * Lightweight HTTP client that facilitates GET, POST, PUT, and DELETE requests
 * using {@link HttpURLConnection}. Extend this class to support specialized
 * content and response types (see {@link BasicHttpClient} for an example). To
 * enable streaming, buffering, or other types of readers / writers, set an
 * alternate {@link RequestHandler}.
 * 
 * @author David M. Chandler
 */
public abstract class AbstractHttpClient {

	static int[] fib = new int[20];
	static {
		// Compute Fibonacci series for backoff
		for (int i = 0; i < 20; i++) {
			fib[i] = i < 2 ? i : fib[i - 2] + fib[i - 1];
		}
	}

	private int maxRetries = 3;

	public static final String URLENCODED = "application/x-www-form-urlencoded;charset=UTF-8";
	public static final String MULTIPART = "multipart/form-data";

	protected String baseUrl = "";

	protected RequestLogger requestLogger = new ConsoleRequestLogger();
	protected final RequestHandler requestHandler;
	private Map<String, String> requestHeaders = new TreeMap<String, String>();
	/**
	 * Default 2s, deliberately short. If you need longer, you should be using
	 * {@link AsyncHttpClient} instead.
	 */
	protected int connectionTimeout = 2000;
	/**
	 * Default 8s, reasonably short if accidentally called from the UI thread.
	 */
	protected int readTimeout = 8000;
	/**
	 * Indicates connection status, used by timeout logic
	 */
	private boolean isConnected;

	/**
	 * Constructs a client with empty baseUrl. Prevent sub-classes from calling
	 * this as it doesn't result in an instance of the subclass.
	 */
	@SuppressWarnings("unused")
	private AbstractHttpClient() {
		this("");
	}

	/**
	 * Constructs a new client with base URL that will be appended in the
	 * request methods. It may be empty or any part of a URL. Examples:
	 * http://turbomanage.com http://turbomanage.com:987
	 * http://turbomanage.com:987/resources
	 * 
	 * @param baseUrl
	 */
	public AbstractHttpClient(String baseUrl) {
		this(baseUrl, new BasicRequestHandler() {
		});
	}

	/**
	 * Construct a client with baseUrl and RequestHandler.
	 * 
	 * @param baseUrl
	 * @param requestHandler
	 */
	public AbstractHttpClient(String baseUrl, RequestHandler requestHandler) {
		this.baseUrl = baseUrl;
		this.requestHandler = requestHandler;
	}
	/**
	 * Execute a HEAD request and return the response. The supplied parameters
	 * are URL encoded and sent as the query string.
	 * 
	 * @param path
	 * @param params
	 * @return Response object
	 * @throws HttpRequestException 
	 */
	public abstract HttpResponse head(String path, ParameterMap params)
			throws HttpRequestException;
	/**
	 * Execute a GET request and return the response. The supplied parameters
	 * are URL encoded and sent as the query string.
	 * 
	 * @param path
	 * @param params
	 * @return Response object
	 * @throws HttpRequestException 
	 */
	public abstract HttpResponse get(String path, ParameterMap params)
			throws HttpRequestException;
	/**
	 * Execute a POST request with parameter map and return the response.
	 * 
	 * @param path
	 * @param params
	 * @return Response object
	 * @throws HttpRequestException 
	 */
	public abstract HttpResponse post(String path, ParameterMap params)
			throws HttpRequestException;
	/**
	 * Execute a POST request with a chunk of data and return the response.
	 * 
	 * To include name-value pairs in the query string, add them to the path
	 * argument or use the constructor in {@link HttpPost}. This is not a common
	 * use case, so it is not included here.
	 * 
	 * @param path
	 * @param contentType
	 * @param data
	 * @return Response object
	 * @throws HttpRequestException 
	 */
	public abstract HttpResponse post(String path, String contentType,
			byte[] data) throws HttpRequestException;
	/**
	 * Execute a PUT request with the supplied content and return the response.
	 * 
	 * To include name-value pairs in the query string, add them to the path
	 * argument or use the constructor in {@link HttpPut}. This is not a common
	 * use case, so it is not included here.
	 * 
	 * @param path
	 * @param contentType
	 * @param data
	 * @return Response object
	 * @throws HttpRequestException 
	 */
	public abstract HttpResponse put(String path, String contentType,
			byte[] data) throws HttpRequestException;
	
	/**
	 * Execute a DELETE request and return the response. The supplied parameters
	 * are URL encoded and sent as the query string.
	 * 
	 * @param path
	 * @param params
	 * @return Response object
	 * @throws HttpRequestException 
	 */
	public abstract HttpResponse delete(String path, ParameterMap params)
			throws HttpRequestException;

	/**
	 * This method wraps the call to doHttpMethod and invokes the custom error
	 * handler in case of exception. It may be overridden by other clients such
	 * {@link AsyncHttpClient} in order to wrap the exception handling for
	 * purposes of retries, etc.
	 * 
	 * @param httpRequest
	 * @return Response object (may be null if request did not complete)
	 */
	public HttpResponse execute(HttpRequest httpRequest) {
		HttpResponse httpResponse = null;
		try {
			httpResponse = doHttpMethod(httpRequest.getPath(),
					httpRequest.getHttpMethod(), httpRequest.getContentType(),
					httpRequest.getContent());
		} catch (HttpRequestException hre) {
			requestHandler.onError(hre);
		} catch (Exception e) {
			// In case a RuntimeException has leaked out, wrap it in HRE
			requestHandler.onError(new HttpRequestException(e, httpResponse));
		}
		return httpResponse;
	}

	/**
	 * Tries several times until successful or maxRetries exhausted. Must throw
	 * exception in order for the async process to forward it to the callback's
	 * onError method.
	 * 
	 * @param httpRequest
	 * @return Response object, may be null
	 * @throws HttpRequestException
	 */
	public HttpResponse tryMany(HttpRequest httpRequest)
			throws HttpRequestException {
		int numTries = 0;
		long startTime = System.currentTimeMillis();
		HttpResponse res = null;
		while (numTries < maxRetries) {
			try {
				setConnectionTimeout(getNextTimeout(numTries));
				if (requestLogger.isLoggingEnabled()) {
					requestLogger.log((numTries + 1) + "of" + maxRetries
							+ ", trying " + httpRequest.getPath());
				}
				startTime = System.currentTimeMillis();
				res = doHttpMethod(httpRequest.getPath(),
						httpRequest.getHttpMethod(),
						httpRequest.getContentType(), httpRequest.getContent());
				if (res != null) {
					return res;
				}
			} catch (HttpRequestException e) {
				if (isTimeoutException(e, startTime)
						&& numTries < (maxRetries - 1)) {
					// Fall through loop, retry
					// On last attempt, throw the exception regardless
				} else {
					boolean isRecoverable = requestHandler.onError(e);
					if (isRecoverable && numTries < (maxRetries - 1)) {
						try {
							// Wait a while and fall through loop to try again
							Thread.sleep(connectionTimeout);
						} catch (InterruptedException ie) {
							// App stopping, perhaps? No point in further
							// retries
							throw e;
						}
					} else {
						// Not recoverable or last attempt, time to bail
						throw e;
					}
				}
			}
			numTries++;
		}
		return null;
	}

	/**
	 * Implements exponential backoff using the Fibonacci series, which has the
	 * effect of backing off with a multiplier of ~1.618 (the golden mean)
	 * instead of 2, which is rather boring.
	 * 
	 * @param numTries
	 *            Current number of attempts completed
	 * @return Connection timeout in ms for next attempt
	 */
	protected int getNextTimeout(int numTries) {
		// For n=0,1,2,3 returns 1000,2000,3000,5000
		return 1000 * fib[numTries + 2];
	}

	/**
	 * Set maximum number of retries to attempt, capped at 18. On the 18th
	 * retry, the connection timeout will be 4,181 sec = 1 hr 9 min.
	 * 
	 * @param maxRetries
	 */
	public void setMaxRetries(int maxRetries) {
		if (maxRetries < 1 || maxRetries > 18) {
			throw new IllegalArgumentException(
					"Maximum retries must be between 1 and 18");
		}
		this.maxRetries = maxRetries;
	}

	/**
	 * This is the method that drives each request. It implements the request
	 * lifecycle defined as open, prepare, write, read. Each of these methods in
	 * turn delegates to the {@link RequestHandler} associated with this client.
	 * 
	 * @param path
	 *            Whole or partial URL string, will be appended to baseUrl
	 * @param httpMethod
	 *            Request method
	 * @param contentType
	 *            MIME type of the request
	 * @param content
	 *            Request data
	 * @return Response object
	 * @throws HttpRequestException
	 */
	@SuppressWarnings("finally")
	protected HttpResponse doHttpMethod(String path, HttpMethod httpMethod,
			String contentType, byte[] content) throws HttpRequestException {

		HttpURLConnection uc = null;
		HttpResponse httpResponse = null;

		try {
			isConnected = false;
			uc = openConnection(path);
			prepareConnection(uc, httpMethod, contentType);
			appendRequestHeaders(uc);
			if (requestLogger.isLoggingEnabled()) {
				requestLogger.logRequest(uc, content);
			}
			// Explicit connect not required, but lets us easily determine when
			// possible timeout exception occurred
			uc.connect();
			isConnected = true;
			if (uc.getDoOutput() && content != null) {
				writeOutputStream(uc, content);
			}
			if (uc.getDoInput()) {
				httpResponse = readInputStream(uc);
			} else {
				httpResponse = new HttpResponse(uc, null);
			}
		} catch (Exception e) {
			// Try reading the error stream to populate status code such as 404
			try {
				httpResponse = readErrorStream(uc);
			} catch (Exception ee) {
				e.printStackTrace();
				// Must catch IOException, but swallow to show first cause only
			} finally {
				// if status available, return it else throw
				if (httpResponse != null && httpResponse.getStatus() > 0) {
					return httpResponse;
				}
				throw new HttpRequestException(e, httpResponse);
			}
		} finally {
			if (requestLogger.isLoggingEnabled()) {
				requestLogger.logResponse(httpResponse);
			}
			if (uc != null) {
				uc.disconnect();
			}
		}
		return httpResponse;
	}

	/**
	 * Validates a URL and opens a connection. This does not actually connect to
	 * a server, but rather opens it on the client only to allow writing to
	 * begin. Delegates the open operation to the {@link RequestHandler}.
	 * 
	 * @param path
	 *            Appended to this client's baseUrl
	 * @return An open connection (or null)
	 * @throws IOException
	 */
	protected HttpURLConnection openConnection(String path) throws IOException {
		String requestUrl = baseUrl + path;
		try {
			new URL(requestUrl);
		} catch (MalformedURLException e) {
			throw new IllegalArgumentException(requestUrl
					+ " is not a valid URL", e);
		}
		return requestHandler.openConnection(requestUrl);
	}

	protected void prepareConnection(HttpURLConnection urlConnection,
			HttpMethod httpMethod, String contentType) throws IOException {
		urlConnection.setConnectTimeout(connectionTimeout);
		urlConnection.setReadTimeout(readTimeout);
		requestHandler
				.prepareConnection(urlConnection, httpMethod, contentType);
	}

	/**
	 * Append all headers added with {@link #addHeader(String, String)} to the
	 * request.
	 * 
	 * @param urlConnection
	 */
	private void appendRequestHeaders(HttpURLConnection urlConnection) {
		for (String name : requestHeaders.keySet()) {
			String value = requestHeaders.get(name);
			urlConnection.setRequestProperty(name, value);
		}
	}

	/**
	 * Writes the request to the server. Delegates I/O to the
	 * {@link RequestHandler}.
	 * 
	 * @param urlConnection
	 * @param content
	 *            to be written
	 * @return HTTP status code
	 * @throws Exception
	 *             in order to force calling code to deal with possible NPEs
	 *             also
	 */
	protected int writeOutputStream(HttpURLConnection urlConnection,
			byte[] content) throws Exception {
		OutputStream out = null;
		try {
			out = requestHandler.openOutput(urlConnection);
			if (out != null) {
				requestHandler.writeStream(out, content);
			}
			return urlConnection.getResponseCode();
		} finally {
			// catch not necessary since method throws Exception
			if (out != null) {
				try {
					out.close();
				} catch (Exception e) {
					// Swallow to show first cause only
				}
			}
		}
	}

	/**
	 * Reads the input stream. Delegates I/O to the {@link RequestHandler}.
	 * 
	 * @param urlConnection
	 * @return HttpResponse, may be null
	 * @throws Exception
	 */
	protected HttpResponse readInputStream(HttpURLConnection urlConnection)
			throws Exception {
		InputStream in = null;
		byte[] responseBody = null;
		try {
			in = requestHandler.openInput(urlConnection);
			if (in != null) {
				responseBody = requestHandler.readStream(in);
			}
			return new HttpResponse(urlConnection, responseBody);
		} finally {
			if (in != null) {
				try {
					in.close();
				} catch (Exception e) {
					// Swallow to avoid dups
				}
			}
		}
	}

	/**
	 * Reads the error stream to get an HTTP status code like 404. Delegates I/O
	 * to the {@link RequestHandler}.
	 * 
	 * @param urlConnection
	 * @return HttpResponse, may be null
	 * @throws Exception
	 */
	protected HttpResponse readErrorStream(HttpURLConnection urlConnection)
			throws Exception {
		InputStream err = null;
		byte[] responseBody = null;
		try {
			err = urlConnection.getErrorStream();
			if (err != null) {
				responseBody = requestHandler.readStream(err);
			}
			return new HttpResponse(urlConnection, responseBody);
		} finally {
			if (err != null) {
				try {
					err.close();
				} catch (Exception e) {
					// Swallow to avoid dups
				}
			}
		}
	}

	/**
	 * Convenience method creates a new ParameterMap to hold query params
	 * 
	 * @return Parameter map
	 */
	public ParameterMap newParams() {
		return new ParameterMap();
	}

	/**
	 * Adds to the headers that will be sent with each request from this client
	 * instance. The request headers added with this method are applied by
	 * calling {@link HttpURLConnection#setRequestProperty(String, String)}
	 * after {@link #prepareConnection(HttpURLConnection, HttpMethod, String)},
	 * so they may supplement or replace headers which have already been set.
	 * Calls to {@link #addHeader(String, String)} may be chained. To clear all
	 * headers added with this method, call {@link #clearHeaders()}.
	 * 
	 * @param name
	 * @param value
	 * @return this client for method chaining
	 */
	public AbstractHttpClient addHeader(String name, String value) {
		requestHeaders.put(name, value);
		return this;
	}

	/**
	 * Clears all request headers that have been added using
	 * {@link #addHeader(String, String)}. This method has no effect on headers
	 * which result from request properties set by this class or its associated
	 * {@link RequestHandler} when preparing the {@link HttpURLConnection}.
	 */
	public void clearHeaders() {
		requestHeaders.clear();
	}

	/**
	 * Returns the {@link CookieManager} associated with this client.
	 * 
	 * @return CookieManager
	 */
	public static CookieManager getCookieManager() {
		return (CookieManager) CookieHandler.getDefault();
	}

	/**
	 * Sets the logger to be used for each request.
	 * 
	 * @param logger
	 */
	public void setRequestLogger(RequestLogger logger) {
		this.requestLogger = logger;
	}

	/**
	 * Initialize the app-wide {@link CookieManager}. This is all that's
	 * necessary to enable all Web requests within the app to automatically send
	 * and receive cookies.
	 */
	protected static void ensureCookieManager() {
		if (CookieHandler.getDefault() == null) {
			CookieHandler.setDefault(new CookieManager());
		}
	}

	/**
	 * Determines whether an exception was due to a timeout. If the elapsed time
	 * is longer than the current timeout, the exception is assumed to be the
	 * result of the timeout.
	 * 
	 * @param t
	 *            Any Throwable
	 * @return true if caused by connection or read timeout
	 */
	protected boolean isTimeoutException(Throwable t, long startTime) {
		long elapsedTime = System.currentTimeMillis() - startTime + 10; // fudge
		if (requestLogger.isLoggingEnabled()) {
			requestLogger.log("ELAPSED TIME = " + elapsedTime + ", CT = "
					+ connectionTimeout + ", RT = " + readTimeout);
		}
		if (isConnected) {
			return elapsedTime >= readTimeout;
		} else {
			return elapsedTime >= connectionTimeout;
		}
	}

	/**
	 * Sets the connection timeout in ms. This is the amount of time that
	 * {@link HttpURLConnection} will wait to successfully connect to the remote
	 * server. The read timeout begins once connection has been established.
	 * 
	 * @param connectionTimeout
	 */
	public void setConnectionTimeout(int connectionTimeout) {
		this.connectionTimeout = connectionTimeout;
	}

	/**
	 * Sets the read timeout in ms, which begins after connection has been made.
	 * For large amounts of data expected, bump this up to make sure you allow
	 * adequate time to receive it.
	 * 
	 * @param readTimeout
	 */
	public void setReadTimeout(int readTimeout) {
		this.readTimeout = readTimeout;
	}

}
